From e3823a3263543dec3f454c6f455b635b699c42b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 5 Oct 2023 09:39:54 +0200 Subject: [PATCH] Class to functional component example 1 (#24181) * Class to functional component example 1 * Fix lint and tests * Fix --------- Co-authored-by: Mattermost Build --- .../__snapshots__/textbox.test.tsx.snap | 60 +++- .../src/components/autosize_textarea.test.tsx | 2 +- .../src/components/autosize_textarea.tsx | 290 +++++++++--------- .../components/common/hooks/useDidUpdate.ts | 20 ++ webapp/platform/types/src/utilities.ts | 3 + 5 files changed, 215 insertions(+), 160 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useDidUpdate.ts diff --git a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap index 943bc10dd0..76494d23a5 100644 --- a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap @@ -36,8 +36,24 @@ exports[`components/TextBox should match snapshot with required props 1`] = ` id="someid" inputComponent={ Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "$$typeof": Symbol(react.memo), + "compare": null, + "type": Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "className": [Function], + "defaultValue": [Function], + "disabled": [Function], + "id": [Function], + "onChange": [Function], + "onHeightChange": [Function], + "onInput": [Function], + "onWidthChange": [Function], + "placeholder": [Function], + "value": [Function], + }, + "render": [Function], + }, } } listComponent={[Function]} @@ -127,8 +143,24 @@ exports[`components/TextBox should throw error when new property is too long 1`] id="someid" inputComponent={ Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "$$typeof": Symbol(react.memo), + "compare": null, + "type": Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "className": [Function], + "defaultValue": [Function], + "disabled": [Function], + "id": [Function], + "onChange": [Function], + "onHeightChange": [Function], + "onInput": [Function], + "onWidthChange": [Function], + "placeholder": [Function], + "value": [Function], + }, + "render": [Function], + }, } } listComponent={[Function]} @@ -218,8 +250,24 @@ exports[`components/TextBox should throw error when value is too long 1`] = ` id="someid" inputComponent={ Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "$$typeof": Symbol(react.memo), + "compare": null, + "type": Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "className": [Function], + "defaultValue": [Function], + "disabled": [Function], + "id": [Function], + "onChange": [Function], + "onHeightChange": [Function], + "onInput": [Function], + "onWidthChange": [Function], + "placeholder": [Function], + "value": [Function], + }, + "render": [Function], + }, } } listComponent={[Function]} diff --git a/webapp/channels/src/components/autosize_textarea.test.tsx b/webapp/channels/src/components/autosize_textarea.test.tsx index 8976ee40c7..0404dc53ef 100644 --- a/webapp/channels/src/components/autosize_textarea.test.tsx +++ b/webapp/channels/src/components/autosize_textarea.test.tsx @@ -4,7 +4,7 @@ import {shallow} from 'enzyme'; import React from 'react'; -import {AutosizeTextarea} from 'components/autosize_textarea'; +import AutosizeTextarea from 'components/autosize_textarea'; describe('components/AutosizeTextarea', () => { test('should match snapshot, init', () => { diff --git a/webapp/channels/src/components/autosize_textarea.tsx b/webapp/channels/src/components/autosize_textarea.tsx index 641da8c4f2..45183b55a6 100644 --- a/webapp/channels/src/components/autosize_textarea.tsx +++ b/webapp/channels/src/components/autosize_textarea.tsx @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import type {ChangeEvent, FormEvent, CSSProperties} from 'react'; +import type {ChangeEvent, FormEvent, HTMLProps} from 'react'; +import React, {useRef, useEffect, useCallback} from 'react'; + +import type {Intersection} from '@mattermost/types/utilities'; type Props = { id?: string; @@ -15,191 +17,173 @@ type Props = { onWidthChange?: (width: number) => void; onInput?: (e: FormEvent) => void; placeholder?: string; - forwardedRef?: ((instance: HTMLTextAreaElement | null) => void) | React.MutableRefObject | null; -} +} & Intersection, HTMLProps>; -export class AutosizeTextarea extends React.PureComponent { - private height: number; +const styles = { + container: { + height: 0, + overflow: 'hidden', + }, + reference: { + display: 'inline-block', + height: 'auto', + width: 'auto', + }, + placeholder: { + overflow: 'hidden', + textOverflow: 'ellipsis', + opacity: 0.5, + pointerEvents: 'none' as const, + position: 'absolute' as const, + whiteSpace: 'nowrap' as const, + background: 'none', + borderColor: 'transparent', + }, +}; - private textarea?: HTMLTextAreaElement; - private referenceRef: React.RefObject; +const AutosizeTextarea = React.forwardRef(({ - constructor(props: Props) { - super(props); + // TODO: The provided `id` is sometimes hard-coded and used to interface with the + // component, e.g. `post_textbox`, so it can't be changed. This would ideally be + // abstracted to avoid passing in an `id` prop at all, but we intentionally maintain + // the old behaviour to address ABC-213. + id = 'autosize_textarea', + disabled, + value, + defaultValue, + onChange, + onHeightChange, + onWidthChange, + onInput, + placeholder, + ...otherProps +}: Props, ref) => { + const height = useRef(0); + const textarea = useRef(); + const referenceRef = useRef(null); - this.height = 0; - - this.referenceRef = React.createRef(); - } - - componentDidMount() { - this.recalculateHeight(); - this.recalculateWidth(); - } - - componentDidUpdate() { - this.recalculateHeight(); - this.recalculateWidth(); - } - - private recalculateHeight = () => { - if (!this.referenceRef.current || !this.textarea) { + const recalculateHeight = () => { + if (!referenceRef.current || !textarea.current) { return; } - const height = (this.referenceRef.current).scrollHeight; - const textarea = this.textarea; + const scrollHeight = referenceRef.current.scrollHeight; + const currentTextarea = textarea.current; - if (height > 0 && height !== this.height) { - const style = getComputedStyle(textarea); + if (scrollHeight > 0 && scrollHeight !== height.current) { + const style = getComputedStyle(currentTextarea); // Directly change the height to avoid circular rerenders - textarea.style.height = `${height}px`; + currentTextarea.style.height = `${scrollHeight}px`; - this.height = height; + height.current = scrollHeight; - this.props.onHeightChange?.(height, parseInt(style.maxHeight || '0', 10)); + onHeightChange?.(scrollHeight, parseInt(style.maxHeight || '0', 10)); } }; - private recalculateWidth = () => { - if (!this.referenceRef) { + const recalculateWidth = () => { + if (!referenceRef.current) { return; } - const width = this.referenceRef.current?.offsetWidth || -1; + const width = referenceRef.current?.offsetWidth || -1; if (width >= 0) { window.requestAnimationFrame(() => { - this.props.onWidthChange?.(width); + onWidthChange?.(width); }); } }; - private setTextareaRef = (textarea: HTMLTextAreaElement) => { - if (this.props.forwardedRef) { - if (typeof this.props.forwardedRef === 'function') { - this.props.forwardedRef(textarea); + const setTextareaRef = useCallback((textareaRef: HTMLTextAreaElement) => { + if (ref) { + if (typeof ref === 'function') { + ref(textareaRef); } else { - this.props.forwardedRef.current = textarea; + ref.current = textareaRef; } } - this.textarea = textarea; + textarea.current = textareaRef; + }, [ref]); + + useEffect(() => { + recalculateHeight(); + recalculateWidth(); + }); + + const heightProps = { + rows: 0, + height: 0, }; - render() { - const props = {...this.props}; + if (height.current <= 0) { + // Set an initial number of rows so that the textarea doesn't appear too large when its first rendered + heightProps.rows = 1; + } else { + heightProps.height = height.current; + } - Reflect.deleteProperty(props, 'onHeightChange'); - Reflect.deleteProperty(props, 'providers'); - Reflect.deleteProperty(props, 'channelId'); - Reflect.deleteProperty(props, 'forwardedRef'); - - const { - value, - defaultValue, - placeholder, - disabled, - onInput, - - // TODO: The provided `id` is sometimes hard-coded and used to interface with the - // component, e.g. `post_textbox`, so it can't be changed. This would ideally be - // abstracted to avoid passing in an `id` prop at all, but we intentionally maintain - // the old behaviour to address ABC-213. - id = 'autosize_textarea', - ...otherProps - } = props; - - const heightProps = { - rows: 0, - height: 0, - }; - - Reflect.deleteProperty(otherProps, 'onWidthChange'); - - if (this.height <= 0) { - // Set an initial number of rows so that the textarea doesn't appear too large when its first rendered - heightProps.rows = 1; - } else { - heightProps.height = this.height; - } - - let textareaPlaceholder = null; - const placeholderAriaLabel = placeholder ? placeholder.toLowerCase() : ''; - if (!this.props.value && !this.props.defaultValue) { - textareaPlaceholder = ( -
- {placeholder} -
- ); - } - - let referenceValue = value || defaultValue; - if (referenceValue?.endsWith('\n')) { - // In a div, the browser doesn't always count characters at the end of a line when measuring the dimensions - // of text. In the spec, they refer to those characters as "hanging". No matter what value we set for the - // `white-space` of a div, a single newline at the end of the div will always hang. - // - // The textarea doesn't have that behaviour, so we need to trick the reference div into measuring that - // newline, and it seems like the best way to do that is by adding a second newline because only the final - // one hangs. - referenceValue += '\n'; - } - - return ( -
- {textareaPlaceholder} -