diff --git a/.eslintrc.js b/.eslintrc.js index ebdbaa72c6f..4142fa78221 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,40 +20,35 @@ const APACHE_2_0_LICENSE_HEADER = ` `; module.exports = { - parser: "@typescript-eslint/parser", + parser: '@typescript-eslint/parser', parserOptions: { ecmaFeatures: { - jsx: true - } + jsx: true, + }, }, settings: { - "import/resolver": { - node: { - extensions: [".ts", ".tsx", ".js", ".json"] + 'import/resolver': { + node: { + extensions: ['.ts', '.tsx', '.js', '.json'], }, webpack: { - config: "./src-docs/webpack.config.js" - } + config: './src-docs/webpack.config.js', + }, }, react: { - version: "detect" - } + version: 'detect', + }, }, extends: [ - "@elastic/eslint-config-kibana", - "plugin:@typescript-eslint/recommended", + '@elastic/eslint-config-kibana', + 'plugin:@typescript-eslint/recommended', // Prettier options need to come last, in order to override other style // rules. - "prettier/react", - "prettier/standard", - "plugin:prettier/recommended" - ], - plugins: [ - "jsx-a11y", - "prettier", - "local", - "react-hooks" + 'prettier/react', + 'prettier/standard', + 'plugin:prettier/recommended', ], + plugins: ['jsx-a11y', 'prettier', 'local', 'react-hooks'], rules: { "prefer-template": "error", "local/i18n": "error", @@ -67,7 +62,6 @@ module.exports = { ], "no-use-before-define": "off", "quotes": ["warn", "single", "avoid-escape"], - "jsx-a11y/accessible-emoji": "error", "jsx-a11y/alt-text": "error", "jsx-a11y/anchor-has-content": "error", @@ -118,6 +112,16 @@ module.exports = { "@typescript-eslint/no-inferrable-types": "off", }, env: { - jest: true - } + jest: true, + }, + overrides: [ + { + files: ['*.d.ts'], + rules: { + 'react/no-multi-comp': 'off', + 'react/prefer-es6-class': 'off', + 'react/prefer-stateless-function': 'off', + }, + }, + ], }; diff --git a/CHANGELOG.md b/CHANGELOG.md index c78669d6f07..005bf726e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `useEuiTextDiff` react hook utility ([#3288](https://github.com/elastic/eui/pull/3288)) + **Bug fixes** - Fixed `EuiCodeBlockImpl` testenv mock pass-through of `data-test-subj` attribute ([#3560](https://github.com/elastic/eui/pull/3560)) diff --git a/package.json b/package.json index d4f0703153e..5ec0d33e562 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-virtualized": "^9.21.2", "resize-observer-polyfill": "^1.5.0", "tabbable": "^3.0.0", + "text-diff": "^1.0.1", "uuid": "^3.1.0" }, "devDependencies": { diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 6882010051d..e4b7790de8e 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -195,6 +195,8 @@ import { TableInMemoryExample } from './views/tables/tables_in_memory_example'; import { TabsExample } from './views/tabs/tabs_example'; +import { TextDiffExample } from './views/text_diff/text_diff_example'; + import { TextExample } from './views/text/text_example'; import { TitleExample } from './views/title/title_example'; @@ -383,6 +385,7 @@ const navigation = [ LoadingExample, ProgressExample, StatExample, + TextExample, TitleExample, ToastExample, @@ -444,6 +447,7 @@ const navigation = [ PrettyDurationExample, ResizeObserverExample, ResponsiveExample, + TextDiffExample, ToggleExample, WindowEventExample, ].map(example => createExample(example)), diff --git a/src-docs/src/views/html_id_generator/html_id_generator_example.js b/src-docs/src/views/html_id_generator/html_id_generator_example.js index 07c652cd538..d882d9efde4 100644 --- a/src-docs/src/views/html_id_generator/html_id_generator_example.js +++ b/src-docs/src/views/html_id_generator/html_id_generator_example.js @@ -27,7 +27,7 @@ const PrefixSufixHtml = renderToHtml(PrefixSufix); const prefixSuffixSnippet = " htmlIdGenerator('prefix')('suffix')"; export const HtmlIdGeneratorExample = { - title: 'HTML ID Generator', + title: 'HTML ID generator', sections: [ { source: [ diff --git a/src-docs/src/views/overlay_mask/overlay_mask_example.js b/src-docs/src/views/overlay_mask/overlay_mask_example.js index e7cbebf23ca..4df9912a382 100644 --- a/src-docs/src/views/overlay_mask/overlay_mask_example.js +++ b/src-docs/src/views/overlay_mask/overlay_mask_example.js @@ -16,7 +16,7 @@ const overlayMaskSnippet = ` `; export const OverlayMaskExample = { - title: 'Overlay Mask', + title: 'Overlay mask', sections: [ { source: [ diff --git a/src-docs/src/views/text_diff/props.tsx b/src-docs/src/views/text_diff/props.tsx new file mode 100644 index 00000000000..859d46b5a6f --- /dev/null +++ b/src-docs/src/views/text_diff/props.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; + +import { EuiTextDiffProps } from '../../../../src/components/text_diff'; + +export const useEuiTextDiffProp: FunctionComponent = () => { + return
; +}; diff --git a/src-docs/src/views/text_diff/text_diff.js b/src-docs/src/views/text_diff/text_diff.js new file mode 100644 index 00000000000..297638dad2b --- /dev/null +++ b/src-docs/src/views/text_diff/text_diff.js @@ -0,0 +1,49 @@ +import React, { useState, useEffect } from 'react'; + +import { + useEuiTextDiff, + EuiCode, + EuiSpacer, + EuiTextColor, + EuiText, +} from '../../../../src/components'; + +export default () => { + const [del, setDel] = useState(0); + const [ins, setIns] = useState(0); + + const beforeText = + 'Orbiting this at a distance of roughly ninety-two million miles is an utterly insignificant little blue green planet whose ape- descended life forms are so amazingly primitive that they still think digital watches are a pretty neat idea.'; + const afterText = + 'Orbiting those at a distance of roughly ninety-nine billion yards is not insignificant dwaf red green planet whose ape- ascended life forms are so amazingly primitive that they still think digital clocks are a pretty neat idea.'; + + const [rendered, textDiffObject] = useEuiTextDiff({ + beforeText, + afterText, + }); + + useEffect(() => { + textDiffObject.forEach(el => { + if (el[0] === 1) { + setIns(add => add + 1); + } else if (el[0] === -1) { + setDel(sub => sub + 1); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +

{rendered}

+
+ + + {ins} Insertions, + {del} + Deletions + + + ); +}; diff --git a/src-docs/src/views/text_diff/text_diff_custom_components.js b/src-docs/src/views/text_diff/text_diff_custom_components.js new file mode 100644 index 00000000000..7460c1e01d9 --- /dev/null +++ b/src-docs/src/views/text_diff/text_diff_custom_components.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import { useEuiTextDiff, EuiCodeBlock } from '../../../../src/components'; + +export default () => { + const beforeText = + 'Orbiting this at a distance of roughly ninety-two million miles is an utterly insignificant little blue green planet whose ape- descended life forms are so amazingly primitive that they still think digital watches are a pretty neat idea.'; + const afterText = + 'Orbiting those at a distance of roughly ninety-nine billion yards is not insignificant dwaf red green planet whose ape- ascended life forms are so amazingly primitive that they still think digital clocks are a pretty neat idea.'; + const [rendered] = useEuiTextDiff({ + beforeText, + afterText, + insertComponent: 'strong', + deleteComponent: 's', + }); + + return ( + + {rendered} + + ); +}; diff --git a/src-docs/src/views/text_diff/text_diff_example.js b/src-docs/src/views/text_diff/text_diff_example.js new file mode 100644 index 00000000000..a4e20e776ee --- /dev/null +++ b/src-docs/src/views/text_diff/text_diff_example.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; + +import { EuiCode } from '../../../../src/components'; +import { useEuiTextDiffProp } from './props'; +import TextDiff from './text_diff'; +const textDiffSource = require('!!raw-loader!./text_diff'); +const textDiffHtml = renderToHtml(TextDiff); + +import TextDiffCustomComponents from './text_diff_custom_components'; +const customComponentsSource = require('!!raw-loader!./text_diff_custom_components'); +const customComponentsHtml = renderToHtml(TextDiffCustomComponents); + +export const TextDiffExample = { + title: 'Text diff', + isNew: true, + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: textDiffSource, + }, + { + type: GuideSectionTypes.HTML, + code: textDiffHtml, + }, + ], + text: ( + <> +

+ The hook, useEuiTextDiff, generates a set of + changes between two strings. It returns both React elements for + displaying the diff and an object representing the identified + changes. The timeout prop is used to set how many + seconds any diff's exploration phase may take. The default + value is 0.1, a value of 0 disables the timeout and lets diff run + until completion. The higher the timeout, the more detailed the + comparison. +

+

+ + { + 'const [rendered, textDiffObject] = useEuiTextDiff({ beforeText, afterText })' + } + +

+ + ), + demo: , + props: { useEuiTextDiffProp }, + snippet: `const [rendered, textDiffObject] = useEuiTextDiff({ beforeText, afterText }) + +

{rendered}

`, + }, + { + title: 'Custom rendered elements', + source: [ + { + type: GuideSectionTypes.JS, + code: customComponentsSource, + }, + { + type: GuideSectionTypes.HTML, + code: customComponentsHtml, + }, + ], + text: ( + <> +

+ By default, the hook will wrap deletions with{' '} + {''} and insertions with{' '} + {''} elements. You can replace these + elements with the deleteComponent and{' '} + insertComponent + props respectively. +

+

+ Also, since rendered is simple html string, you + can wrap it in any contextual element like{' '} + + EuiText + {' '} + or{' '} + + EuiCodeBlock + + . +

+ + ), + demo: , + snippet: `const [rendered] = useEuiTextDiff({ + beforeText, + afterText, + insertComponent: 'strong', +}); + + + {rendered} +`, + }, + ], +}; diff --git a/src/components/index.js b/src/components/index.js index 63b39e01e3c..ef981a0b58c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -322,6 +322,8 @@ export { EuiTab, EuiTabs, EuiTabbedContent } from './tabs'; export { EuiText, EuiTextColor, EuiTextAlign } from './text'; +export { useEuiTextDiff } from './text_diff'; + export { EuiTitle } from './title'; export { EuiGlobalToastList, EuiGlobalToastListItem, EuiToast } from './toast'; diff --git a/src/components/index.scss b/src/components/index.scss index 75bbd1b3bc1..929679dc210 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -63,6 +63,7 @@ @import 'suggest/index'; @import 'table/index'; @import 'tabs/index'; +@import 'text_diff/index'; @import 'title/index'; @import 'toast/index'; @import 'token/index'; diff --git a/src/components/text_diff/__snapshots__/text-diff.test.tsx.snap b/src/components/text_diff/__snapshots__/text-diff.test.tsx.snap new file mode 100644 index 00000000000..4993c4c70d7 --- /dev/null +++ b/src/components/text_diff/__snapshots__/text-diff.test.tsx.snap @@ -0,0 +1,349 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useEuiTextDiff is rendered 1`] = ` + +
+ Orbiting th + + i + + + o + + s + + e + + at a distance of roughly ninety- + + two + + + nine + + + + m + + + b + + illion + + mile + + + yard + + s is + + a + + n + + ut + + + o + + t + + erly + + insignificant + + little + + + dwaf + + + + blu + + + r + + e + + d + + green planet whose ape- + + de + + + a + + scended life forms are so amazingly primitive that they still think digital + + wat + + + clo + + c + + he + + + k + + s are a pretty neat idea. +
+
+`; + +exports[`useEuiTextDiff props custom components is rendered 1`] = ` + +
+

+ Orbiting th +

+ + i + + + o + +

+ s +

+ + e + +

+ at a distance of roughly ninety- +

+ + two + + + nine + +

+ +

+ + m + + + b + +

+ illion +

+ + mile + + + yard + +

+ s is +

+ + a + +

+ n +

+ + ut + + + o + +

+ t +

+ + erly + +

+ insignificant +

+ + little + + + dwaf + +

+ +

+ + blu + + + r + +

+ e +

+ + d + +

+ green planet whose ape- +

+ + de + + + a + +

+ scended life forms are so amazingly primitive that they still think digital +

+ + wat + + + clo + +

+ c +

+ + he + + + k + +

+ s are a pretty neat idea. +

+
+
+`; diff --git a/src/components/text_diff/_index.scss b/src/components/text_diff/_index.scss new file mode 100644 index 00000000000..e4b6a8c0360 --- /dev/null +++ b/src/components/text_diff/_index.scss @@ -0,0 +1 @@ +@import 'text_diff'; diff --git a/src/components/text_diff/_text_diff.scss b/src/components/text_diff/_text_diff.scss new file mode 100644 index 00000000000..6e38229934d --- /dev/null +++ b/src/components/text_diff/_text_diff.scss @@ -0,0 +1,9 @@ +.euiTextDiff { + del { + color: $euiColorDangerText; + } + + ins { + color: $euiColorSuccessText; + } +} diff --git a/src/components/text_diff/index.ts b/src/components/text_diff/index.ts new file mode 100644 index 00000000000..4a733d52cc9 --- /dev/null +++ b/src/components/text_diff/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { useEuiTextDiff, EuiTextDiffProps } from './text_diff'; diff --git a/src/components/text_diff/text-diff.d.ts b/src/components/text_diff/text-diff.d.ts new file mode 100644 index 00000000000..1462884fd85 --- /dev/null +++ b/src/components/text_diff/text-diff.d.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +declare module 'text-diff' { + interface ConstructorProps { + timeout: number; + } + + type DiffElement = [-1 | 0 | 1, string]; + + class Diff { + constructor({ timeout }: ConstructorProps); + main: (initialText: string, currentText: string) => DiffElement[]; + } + export = Diff; +} diff --git a/src/components/text_diff/text-diff.test.tsx b/src/components/text_diff/text-diff.test.tsx new file mode 100644 index 00000000000..3f8b19e2c8d --- /dev/null +++ b/src/components/text_diff/text-diff.test.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { useEuiTextDiff } from './text_diff'; +const beforeText = + 'Orbiting this at a distance of roughly ninety-two million miles is an utterly insignificant little blue green planet whose ape- descended life forms are so amazingly primitive that they still think digital watches are a pretty neat idea.'; +const afterText = + 'Orbiting those at a distance of roughly ninety-nine billion yards is not insignificant dwaf red green planet whose ape- ascended life forms are so amazingly primitive that they still think digital clocks are a pretty neat idea.'; + +describe('useEuiTextDiff', () => { + test('is rendered', () => { + const Element = () => { + const renderedComponent = useEuiTextDiff({ + beforeText, + afterText, + timeout: 0, + })[0]; + return <>{renderedComponent}; + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + describe('custom components', () => { + test('is rendered', () => { + const Element = () => { + const renderedComponent = useEuiTextDiff({ + beforeText, + afterText, + timeout: 0, + insertComponent: 'strong', + deleteComponent: 's', + sameComponent: 'p', + })[0]; + return <>{renderedComponent}; + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/components/text_diff/text_diff.tsx b/src/components/text_diff/text_diff.tsx new file mode 100644 index 00000000000..d600a3d6767 --- /dev/null +++ b/src/components/text_diff/text_diff.tsx @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { HTMLAttributes, useMemo, ElementType } from 'react'; +import Diff from 'text-diff'; +import classNames from 'classnames'; +import { CommonProps } from '../common'; + +interface Props { + /** + * The starting string + */ + beforeText: string; + /** + * The string used to compare against `beforeText` + */ + afterText: string; + /** + * HTML element to wrap insertion differences. + * Defaults to `ins` + */ + insertComponent?: ElementType; + /** + * HTML element to wrap deletion differences. + * Defaults to `del` + */ + deleteComponent?: ElementType; + /** + * HTML element to wrap text with no differences. + * Doesn't wrap with anything by default + */ + sameComponent?: ElementType; + /** + * Time in milliseconds. Passing a timeout of value '0' disables the timeout state + */ + timeout?: number; +} + +export type EuiTextDiffProps = CommonProps & + Props & + HTMLAttributes; + +export const useEuiTextDiff = ({ + className, + insertComponent = 'ins', + deleteComponent = 'del', + sameComponent, + beforeText = '', + afterText = '', + timeout = 0.1, + ...rest +}: EuiTextDiffProps) => { + const textDiff = useMemo(() => { + const diff = new Diff({ timeout }); // options may be passed to constructor + + return diff.main(beforeText, afterText); + }, [beforeText, afterText, timeout]); // produces diff array + + const classes = classNames('euiTextDiff', className); + + const rendereredHtml = useMemo(() => { + const html = []; + if (textDiff) + for (let i = 0; i < textDiff.length; i++) { + let Element; + const el = textDiff[i]; + if (el[0] === 1) Element = insertComponent; + else if (el[0] === -1) Element = deleteComponent; + else if (sameComponent) Element = sameComponent; + if (Element) html.push({el[1]}); + else html.push(el[1]); + } + + return html; + }, [textDiff, deleteComponent, insertComponent, sameComponent]); // produces diff array + + return [ +
+ {rendereredHtml} +
, + textDiff, + ]; +}; diff --git a/yarn.lock b/yarn.lock index 7078bdb955d..bc42076bd55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15362,6 +15362,11 @@ test-exclude@^5.0.0: read-pkg-up "^4.0.0" require-main-filename "^1.0.1" +text-diff@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/text-diff/-/text-diff-1.0.1.tgz#6c105905435e337857375c9d2f6ca63e453ff565" + integrity sha1-bBBZBUNeM3hXN1ydL2ymPkU/9WU= + text-table@^0.2.0, text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"