diff --git a/app/.eslintrc b/app/.eslintrc index 639b5e8e2..f187c4aef 100644 --- a/app/.eslintrc +++ b/app/.eslintrc @@ -1,6 +1,7 @@ { "extends": [ "../.eslintrc", + "plugin:react/recommended", "plugin:react-hooks/recommended", ], "plugins": [ diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 5c1cdce1b..a3f8917e3 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -61,8 +61,11 @@ export const openDocument = createAction({ // cache if the document is renamed id: path.url, name: path.title, - icon: () => - stores.documents.get(path.id)?.isStarred ? : null, + icon: function _Icon() { + return stores.documents.get(path.id)?.isStarred ? ( + + ) : null; + }, section: DocumentSection, perform: () => history.push(path.url), })); diff --git a/app/actions/definitions/settings.tsx b/app/actions/definitions/settings.tsx index 5405c606f..fa2b7cf75 100644 --- a/app/actions/definitions/settings.tsx +++ b/app/actions/definitions/settings.tsx @@ -43,8 +43,9 @@ export const changeTheme = createAction({ isContextMenu ? t("Appearance") : t("Change theme"), analyticsName: "Change theme", placeholder: ({ t }) => t("Change theme to"), - icon: () => - stores.ui.resolvedTheme === "light" ? : , + icon: function _Icon() { + return stores.ui.resolvedTheme === "light" ? : ; + }, keywords: "appearance display", section: SettingsSection, children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme], diff --git a/app/actions/definitions/teams.tsx b/app/actions/definitions/teams.tsx index 0d0cae66c..4b2959b62 100644 --- a/app/actions/definitions/teams.tsx +++ b/app/actions/definitions/teams.tsx @@ -16,18 +16,20 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) => analyticsName: "Switch workspace", section: TeamSection, keywords: "change switch workspace organization team", - icon: () => ( - - ), + icon: function _Icon() { + return ( + + ); + }, visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id, perform: () => (window.location.href = session.url), })) ?? []; diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx index f106c3f95..d2b686182 100644 --- a/app/components/ActionButton.tsx +++ b/app/components/ActionButton.tsx @@ -1,8 +1,9 @@ +/* eslint-disable react/prop-types */ import * as React from "react"; import Tooltip, { Props as TooltipProps } from "~/components/Tooltip"; import { Action, ActionContext } from "~/types"; -export type Props = React.ComponentPropsWithoutRef<"button"> & { +export type Props = React.HTMLAttributes & { /** Show the button in a disabled state */ disabled?: boolean; /** Hide the button entirely if action is not applicable */ @@ -18,11 +19,11 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & { /** * Button that can be used to trigger an action definition. */ -const ActionButton = React.forwardRef( - ( +const ActionButton = React.forwardRef( + function _ActionButton( { action, context, tooltip, hideOnActionDisabled, ...rest }: Props, ref: React.Ref - ) => { + ) { const [executing, setExecuting] = React.useState(false); const disabled = rest.disabled; diff --git a/app/components/Analytics.tsx b/app/components/Analytics.tsx index d942668c5..b1da7e3c0 100644 --- a/app/components/Analytics.tsx +++ b/app/components/Analytics.tsx @@ -5,7 +5,11 @@ import * as React from "react"; import { IntegrationService } from "@shared/types"; import env from "~/env"; -const Analytics: React.FC = ({ children }) => { +type Props = { + children?: React.ReactNode; +}; + +const Analytics: React.FC = ({ children }: Props) => { // Google Analytics 3 React.useEffect(() => { if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) { diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index d23cb6e71..cad6c54ac 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -37,7 +37,11 @@ const DocumentInsights = lazyWithRetry( ); const CommandBar = lazyWithRetry(() => import("~/components/CommandBar")); -const AuthenticatedLayout: React.FC = ({ children }) => { +type Props = { + children?: React.ReactNode; +}; + +const AuthenticatedLayout: React.FC = ({ children }: Props) => { const { ui, auth } = useStores(); const location = useLocation(); const can = usePolicy(ui.activeCollectionId); diff --git a/app/components/CenteredContent.tsx b/app/components/CenteredContent.tsx index 7cd5e24d9..f3405751a 100644 --- a/app/components/CenteredContent.tsx +++ b/app/components/CenteredContent.tsx @@ -3,6 +3,7 @@ import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; type Props = { + children?: React.ReactNode; withStickyHeader?: boolean; }; @@ -26,7 +27,7 @@ const Content = styled.div` `}; `; -const CenteredContent: React.FC = ({ children, ...rest }) => ( +const CenteredContent: React.FC = ({ children, ...rest }: Props) => ( {children} diff --git a/app/components/CommandBar.tsx b/app/components/CommandBar.tsx index 58103dd89..df3deb3fb 100644 --- a/app/components/CommandBar.tsx +++ b/app/components/CommandBar.tsx @@ -52,7 +52,11 @@ function CommandBar() { ); } -const KBarPortal: React.FC = ({ children }) => { +type Props = { + children?: React.ReactNode; +}; + +const KBarPortal: React.FC = ({ children }: Props) => { const { showing } = useKBar((state) => ({ showing: state.visualState !== "hidden", })); diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx index 74077fc29..fcd9c2321 100644 --- a/app/components/ConfirmationDialog.tsx +++ b/app/components/ConfirmationDialog.tsx @@ -17,6 +17,7 @@ type Props = { danger?: boolean; /** Keep the submit button disabled */ disabled?: boolean; + children?: React.ReactNode; }; const ConfirmationDialog: React.FC = ({ @@ -26,7 +27,7 @@ const ConfirmationDialog: React.FC = ({ savingText, danger, disabled = false, -}) => { +}: Props) => { const [isSaving, setIsSaving] = React.useState(false); const { dialogs } = useStores(); const { showToast } = useToasts(); diff --git a/app/components/ContentEditable.tsx b/app/components/ContentEditable.tsx index e10879bc2..b423c94de 100644 --- a/app/components/ContentEditable.tsx +++ b/app/components/ContentEditable.tsx @@ -30,144 +30,138 @@ export type RefHandle = { * Defines a content editable component with the same interface as a native * HTMLInputElement (or, as close as we can get). */ -const ContentEditable = React.forwardRef( - ( - { - disabled, - onChange, - onInput, - onBlur, - onKeyDown, - value, - children, - className, - maxLength, - autoFocus, - placeholder, - readOnly, - dir, - onClick, - ...rest - }: Props, - ref: React.RefObject - ) => { - const contentRef = React.useRef(null); - const [innerValue, setInnerValue] = React.useState(value); - const lastValue = React.useRef(value); +const ContentEditable = React.forwardRef(function _ContentEditable( + { + disabled, + onChange, + onInput, + onBlur, + onKeyDown, + value, + children, + className, + maxLength, + autoFocus, + placeholder, + readOnly, + dir, + onClick, + ...rest + }: Props, + ref: React.RefObject +) { + const contentRef = React.useRef(null); + const [innerValue, setInnerValue] = React.useState(value); + const lastValue = React.useRef(value); - React.useImperativeHandle(ref, () => ({ - focus: () => { - if (contentRef.current) { - contentRef.current.focus(); - // looks unnecessary but required because of https://github.com/outline/outline/issues/5198 - if (!contentRef.current.innerText) { - placeCaret(contentRef.current, true); - } - } - }, - focusAtStart: () => { - if (contentRef.current) { - contentRef.current.focus(); + React.useImperativeHandle(ref, () => ({ + focus: () => { + if (contentRef.current) { + contentRef.current.focus(); + // looks unnecessary but required because of https://github.com/outline/outline/issues/5198 + if (!contentRef.current.innerText) { placeCaret(contentRef.current, true); } - }, - focusAtEnd: () => { - if (contentRef.current) { - contentRef.current.focus(); - placeCaret(contentRef.current, false); - } - }, - getComputedDirection: () => { - if (contentRef.current) { - return window.getComputedStyle(contentRef.current).direction; - } - return "ltr"; - }, - })); - - const wrappedEvent = - ( - callback: - | React.FocusEventHandler - | React.FormEventHandler - | React.KeyboardEventHandler - | undefined - ) => - (event: any) => { - if (readOnly) { - return; - } - - const text = event.currentTarget.textContent || ""; - - if ( - maxLength && - isPrintableKeyEvent(event) && - text.length >= maxLength - ) { - event?.preventDefault(); - return; - } - - if (text !== lastValue.current) { - lastValue.current = text; - onChange?.(text); - } - - callback?.(event); - }; - - // This is to account for being within a React.Suspense boundary, in this - // case the component may be rendered with display: none. React 18 may solve - // this in the future by delaying useEffect hooks: - // https://github.com/facebook/react/issues/14536#issuecomment-861980492 - const isVisible = useOnScreen(contentRef); - - React.useEffect(() => { - if (autoFocus && isVisible && !disabled && !readOnly) { - contentRef.current?.focus(); } - }, [autoFocus, disabled, isVisible, readOnly, contentRef]); - - React.useEffect(() => { - if (contentRef.current && value !== contentRef.current.textContent) { - setInnerValue(value); + }, + focusAtStart: () => { + if (contentRef.current) { + contentRef.current.focus(); + placeCaret(contentRef.current, true); } - }, [value, contentRef]); + }, + focusAtEnd: () => { + if (contentRef.current) { + contentRef.current.focus(); + placeCaret(contentRef.current, false); + } + }, + getComputedDirection: () => { + if (contentRef.current) { + return window.getComputedStyle(contentRef.current).direction; + } + return "ltr"; + }, + })); - // Ensure only plain text can be pasted into input when pasting from another - // rich text source. Note: If `onPaste` prop is passed then it takes - // priority over this behavior. - const handlePaste = React.useCallback( - (event: React.ClipboardEvent) => { - event.preventDefault(); - const text = event.clipboardData.getData("text/plain"); - window.document.execCommand("insertText", false, text); - }, - [] - ); + const wrappedEvent = + ( + callback: + | React.FocusEventHandler + | React.FormEventHandler + | React.KeyboardEventHandler + | undefined + ) => + (event: any) => { + if (readOnly) { + return; + } - return ( -
- - {innerValue} - - {children} -
- ); - } -); + const text = event.currentTarget.textContent || ""; + + if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) { + event?.preventDefault(); + return; + } + + if (text !== lastValue.current) { + lastValue.current = text; + onChange?.(text); + } + + callback?.(event); + }; + + // This is to account for being within a React.Suspense boundary, in this + // case the component may be rendered with display: none. React 18 may solve + // this in the future by delaying useEffect hooks: + // https://github.com/facebook/react/issues/14536#issuecomment-861980492 + const isVisible = useOnScreen(contentRef); + + React.useEffect(() => { + if (autoFocus && isVisible && !disabled && !readOnly) { + contentRef.current?.focus(); + } + }, [autoFocus, disabled, isVisible, readOnly, contentRef]); + + React.useEffect(() => { + if (contentRef.current && value !== contentRef.current.textContent) { + setInnerValue(value); + } + }, [value, contentRef]); + + // Ensure only plain text can be pasted into input when pasting from another + // rich text source. Note: If `onPaste` prop is passed then it takes + // priority over this behavior. + const handlePaste = React.useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + window.document.execCommand("insertText", false, text); + }, + [] + ); + + return ( +
+ + {innerValue} + + {children} +
+ ); +}); function placeCaret(element: HTMLElement, atStart: boolean) { if ( diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx index dace2fba3..858cc43b3 100644 --- a/app/components/ContextMenu/MenuItem.tsx +++ b/app/components/ContextMenu/MenuItem.tsx @@ -22,6 +22,7 @@ type Props = { level?: number; icon?: React.ReactElement; children?: React.ReactNode; + ref?: React.LegacyRef | undefined; }; const MenuItem = ( @@ -80,7 +81,7 @@ const MenuItem = ( ); }, - [active, as, hide, icon, onClick, ref, selected] + [active, as, hide, icon, onClick, ref, children, selected] ); return ( diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx index 4ebebaada..c66a24088 100644 --- a/app/components/ContextMenu/Template.tsx +++ b/app/components/ContextMenu/Template.tsx @@ -44,37 +44,35 @@ type SubMenuProps = MenuStateReturn & { title: React.ReactNode; }; -const SubMenu = React.forwardRef( - ( - { templateItems, title, parentMenuState, ...rest }: SubMenuProps, - ref: React.LegacyRef - ) => { - const { t } = useTranslation(); - const theme = useTheme(); - const menu = useMenuState(); +const SubMenu = React.forwardRef(function _Template( + { templateItems, title, parentMenuState, ...rest }: SubMenuProps, + ref: React.LegacyRef +) { + const { t } = useTranslation(); + const theme = useTheme(); + const menu = useMenuState(); - return ( - <> - - {(props) => ( - - {title} - - )} - - - -