From 78093316ea12b6ad55b96869dc09c17c6aa59ca4 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Fri, 10 Dec 2021 01:23:11 -0600 Subject: [PATCH] reactify create-channel overlay also add refs --- .../webapp/elements/components/button.tsx | 10 +- .../webapp/elements/components/input-text.tsx | 21 ++- .../components/submit-overlay-lower.tsx | 33 ++--- .../webapp/elements/context-menu-conn.tsx | 2 - .../elements/context-menu-guild-title.tsx | 6 +- .../overlays/overlay-create-channel.tsx | 128 ++++++++++++++++++ .../elements/overlays/overlay-personalize.tsx | 51 ++++--- .../webapp/elements/require/elements-util.tsx | 22 +++ src/client/webapp/styles/overlays.scss | 33 +---- 9 files changed, 221 insertions(+), 85 deletions(-) create mode 100644 src/client/webapp/elements/overlays/overlay-create-channel.tsx diff --git a/src/client/webapp/elements/components/button.tsx b/src/client/webapp/elements/components/button.tsx index f83b38e..1006847 100644 --- a/src/client/webapp/elements/components/button.tsx +++ b/src/client/webapp/elements/components/button.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, Ref, useCallback, useMemo } from 'react'; export enum ButtonType { BRAND = '', @@ -8,6 +8,8 @@ export enum ButtonType { } interface ButtonProps { + ref?: Ref; + type?: ButtonType; onClick?: () => void; shaking?: boolean; @@ -18,7 +20,7 @@ const DefaultButtonProps: ButtonProps = { type: ButtonType.BRAND } -const Button: FC = (props: ButtonProps) => { +const Button: FC = React.forwardRef((props: ButtonProps, ref: Ref) => { const { type, onClick, shaking, children } = { ...DefaultButtonProps, ...props }; const className = useMemo( @@ -35,7 +37,7 @@ const Button: FC = (props: ButtonProps) => { if (onClick) onClick(); }, [ shaking, onClick ]); - return
{children}
-} + return
{children}
+}); export default Button; \ No newline at end of file diff --git a/src/client/webapp/elements/components/input-text.tsx b/src/client/webapp/elements/components/input-text.tsx index 5d47459..5be7c20 100644 --- a/src/client/webapp/elements/components/input-text.tsx +++ b/src/client/webapp/elements/components/input-text.tsx @@ -3,9 +3,11 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, Ref, useEffect, useMemo } from 'react'; export interface TextInputProps { + ref?: Ref; + label: string; placeholder?: string; @@ -21,10 +23,11 @@ export interface TextInputProps { setMessage: React.Dispatch>; onEnterKeyDown?: () => void; + valueMap?: (value: string) => string; // useful to get rid of certain characters, convert to lower case, etc. } -const TextInput: FC = (props: TextInputProps) => { - const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown } = props; +const TextInput: FC = React.forwardRef((props: TextInputProps, ref: Ref) => { + const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown, valueMap } = props; useEffect(() => { if (maxLength !== undefined && value.length > maxLength) { @@ -46,11 +49,17 @@ const TextInput: FC = (props: TextInputProps) => { }, [ label ]); const handleChange = (e: React.ChangeEvent) => { - const value = e.target.value; + let value = e.target.value; + if (valueMap) value = valueMap(value); setValue(value); } const handleKeyDown = (e: React.KeyboardEvent) => { + // const controlKeys = [ 'Backspace', 'Escape', 'Tab' ]; + // if (!controlKeys.includes(e.key) && validKeys && !validKeys.test(e.key)) { + // e.preventDefault(); + // e.stopPropagation(); + // } else if (e.key === 'Enter') { if (onEnterKeyDown) onEnterKeyDown(); } @@ -59,9 +68,9 @@ const TextInput: FC = (props: TextInputProps) => { return (
{/* TODO: remove -react */} {labelElement} - +
); -} +}); export default TextInput; diff --git a/src/client/webapp/elements/components/submit-overlay-lower.tsx b/src/client/webapp/elements/components/submit-overlay-lower.tsx index 2954eb2..c21ff94 100644 --- a/src/client/webapp/elements/components/submit-overlay-lower.tsx +++ b/src/client/webapp/elements/components/submit-overlay-lower.tsx @@ -1,22 +1,22 @@ -import React, { FC, useMemo, useState } from 'react'; -import ElementsUtil from '../require/elements-util'; +import React, { FC, Ref, useMemo } from 'react'; import Button from './button'; // Includes a submit button and error message export interface SubmitOverlayLowerProps { + ref?: Ref; + buttonMessage: string; - doSubmit: () => Promise; + submitting: boolean; + submitFailed: boolean; + shaking: boolean; + onSubmit: () => void; errorMessage: string | null; infoMessage?: string | null; } -const SubmitOverlayLower: FC = (props: SubmitOverlayLowerProps) => { - const { buttonMessage, doSubmit, errorMessage, infoMessage } = props; - - const [ shaking, setShaking ] = useState(false) - const [ submitting, setSubmitting ] = useState(false); - const [ submitFailed, setSubmitFailed ] = useState(false); +const SubmitOverlayLower: FC = React.forwardRef((props: SubmitOverlayLowerProps, ref: Ref) => { + const { buttonMessage, submitting, submitFailed, shaking, onSubmit, errorMessage, infoMessage } = props; const buttonText = useMemo(() => { if (submitting) { @@ -28,17 +28,6 @@ const SubmitOverlayLower: FC = (props: SubmitOverlayLow } }, [ submitting, submitFailed ]); - const onSubmit = async () => { - setSubmitting(true); - const succeeded = await doSubmit(); - setSubmitting(false); - - setSubmitFailed(!succeeded); - if (!succeeded) { - await ElementsUtil.delayToggleState(setShaking, 400); - } - } - const message = useMemo(() => errorMessage ?? infoMessage ?? null, [ errorMessage, infoMessage ]); const isEmpty = useMemo(() => message === null, [ message ]); @@ -48,10 +37,10 @@ const SubmitOverlayLower: FC = (props: SubmitOverlayLow
{errorMessage ?? infoMessage ?? null}
- +
); -} +}); export default SubmitOverlayLower; diff --git a/src/client/webapp/elements/context-menu-conn.tsx b/src/client/webapp/elements/context-menu-conn.tsx index 42faf2a..7231b44 100644 --- a/src/client/webapp/elements/context-menu-conn.tsx +++ b/src/client/webapp/elements/context-menu-conn.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import ReactHelper from './require/react-helper.js'; import ElementsUtil from './require/elements-util.js'; import BaseElements from './require/base-elements.js'; -import createPersonalizeOverlay from './overlay-personalize.js'; import Q from '../q-module.js'; import UI from '../ui.js'; import CombinedGuild from '../guild-combined.js'; diff --git a/src/client/webapp/elements/context-menu-guild-title.tsx b/src/client/webapp/elements/context-menu-guild-title.tsx index 838feb6..3ac5ebe 100644 --- a/src/client/webapp/elements/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/context-menu-guild-title.tsx @@ -18,6 +18,7 @@ import React from 'react'; import ReactHelper from './require/react-helper'; import GuildSettingsOverlay from './overlays/overlay-guild-settings'; import ErrorMessageOverlay from './overlays/overlay-error-message'; +import CreateChannelOverlay from './overlays/overlay-create-channel'; export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): Element { if (ui.activeConnection === null) { @@ -93,10 +94,7 @@ export default function createGuildTitleContextMenu(document: Document, q: Q, ui if (ui.activeConnection.privileges.includes('modify_channels')) { q.$$$(element, '.item.create-channel').addEventListener('click', () => { element.removeSelf(); - const overlay = createCreateChannelOverlay(document, q, guild); - document.body.appendChild(overlay); - q.$$$(overlay, '.text-input.channel-name').focus(); - ElementsUtil.setCursorToEnd(q.$$$(overlay, '.text-input.channel-name')); + ElementsUtil.presentReactOverlay(document, ); }); } diff --git a/src/client/webapp/elements/overlays/overlay-create-channel.tsx b/src/client/webapp/elements/overlays/overlay-create-channel.tsx new file mode 100644 index 0000000..05ef993 --- /dev/null +++ b/src/client/webapp/elements/overlays/overlay-create-channel.tsx @@ -0,0 +1,128 @@ +import * as electronRemote from '@electron/remote'; +const electronConsole = electronRemote.getGlobal('console') as Console; +import Logger from '../../../../logger/logger'; +const LOG = Logger.create(__filename, electronConsole); + +import React, { createRef, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import CombinedGuild from '../../guild-combined'; +import BaseElements from '../require/base-elements'; +import TextInput from '../components/input-text'; +import SubmitOverlayLower from '../components/submit-overlay-lower'; +import Globals from '../../globals'; +import ElementsUtil from '../require/elements-util'; + +export interface CreateChannelOverlayProps { + document: Document; + guild: CombinedGuild; +} +const CreateChannelOverlay: FC = (props: CreateChannelOverlayProps) => { + const { document, guild } = props; + + const nameInputRef = createRef(); + const flavorTextInputRef = createRef(); + const submitButtonRef = createRef(); + + const [ edited, setEdited ] = useState(false); + const [ submitting, setSubmitting ] = useState(false); + const [ submitFailed, setSubmitFailed ] = useState(false); + const [ shaking, setShaking ] = useState(false); + + const [ name, setName ] = useState(''); + const [ flavorText, setFlavorText ] = useState(''); + + const [ queryFailed, setQueryFailed ] = useState(false); + + const [ nameInputValid, setNameInputValid ] = useState(false); + const [ nameInputMessage, setNameInputMessage ] = useState(null); + + const [ flavorTextInputValid, setFlavorTextInputValid ] = useState(false); + const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState(null); + + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + const errorMessage = useMemo(() => { + if (!edited) return null; + if ( !nameInputValid && nameInputMessage) return nameInputMessage; + if (!flavorTextInputValid && flavorTextInputMessage) return flavorTextInputMessage; + if (queryFailed) return 'Unable to create new channel'; + return null; + }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage, queryFailed ]); + + const infoMessage = useMemo(() => { + if ( nameInputValid && nameInputMessage) return nameInputMessage; + if (flavorTextInputValid && flavorTextInputMessage) return flavorTextInputMessage; + return null; + }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); + + useEffect(() => { + if (name.length > 0 || flavorText.length > 0) { + setEdited(true); + } + }, [ name, flavorText ]); + + const doSubmit = useCallback(async (): Promise => { + LOG.debug('submitting'); + if (!edited) return false; + if (errorMessage) return false; + + try { + // Make sure to null out flavor text if empty + await guild.requestDoCreateChannel(name, flavorText === '' ? null : flavorText); + } catch (e: unknown) { + LOG.error('error creating channel', e); + setQueryFailed(true); + return false; + } + + ElementsUtil.closeReactOverlay(document); + + return true; + }, [ errorMessage, name, flavorText ]); + + const onSubmit = useCallback( + ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), + [ doSubmit, shaking, submitFailed, submitFailed, submitting ] + ); + + return ( +
+
+
{BaseElements.TEXT_CHANNEL_ICON}
+
{name}
+
+
{flavorText}
+
+
+ value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')} + onEnterKeyDown={() => flavorTextInputRef.current?.focus()} + /> +
+
+ { submitButtonRef.current?.click(); }} + /> +
+ +
+ ); +} +export default CreateChannelOverlay; \ No newline at end of file diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index a3222b7..b9e799b 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -4,7 +4,7 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import moment from 'moment'; -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { createRef, FC, useCallback, useEffect, useMemo, useState } from 'react'; import { ConnectionInfo } from '../../data-types'; import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; @@ -27,6 +27,12 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const avatarResourceId = connection.avatarResourceId; + const displayNameInputRef = createRef(); + + const [ submitting, setSubmitting ] = useState(false); + const [ submitFailed, setSubmitFailed ] = useState(false); + const [ shaking, setShaking ] = useState(false); + const [ savedDisplayName, setSavedDisplayName ] = useState(connection.displayName); const [ savedAvatarBuff, setSavedAvatarBuff ] = useState(null); @@ -55,28 +61,22 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl })(); }, []); + useEffect(() => { + displayNameInputRef.current?.focus(); + }, []); + const errorMessage = useMemo(() => { - if (loadAvatarFailed) { - return 'Unable to load avatar'; - } else if (!avatarInputValid && avatarInputMessage) { - return avatarInputMessage; - } else if (!displayNameInputValid && displayNameInputMessage) { - return displayNameInputMessage; - } else if (saveFailed) { - return 'Unable to save personalization'; - } else { - return null; - } + if (loadAvatarFailed) return 'Unable to load avatar'; + if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; + if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; + if (saveFailed) return 'Unable to save personalization'; + return null; }, [ saveFailed, loadAvatarFailed, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); const infoMessage = useMemo(() => { - if (avatarInputValid && avatarInputMessage) { - return avatarInputMessage; - } else if (displayNameInputValid && displayNameInputMessage) { - return displayNameInputMessage; - } else { - return null; - } + if (avatarInputValid && avatarInputMessage) return avatarInputMessage; + if (displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; + return null; }, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); const doSubmit = useCallback(async (): Promise => { @@ -111,6 +111,11 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl return true; }, [ errorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ]); + const onSubmit = useCallback( + ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), + [ doSubmit, shaking, submitFailed, submitFailed, submitting ] + ); + return (
@@ -123,14 +128,20 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl
- + ); } diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index ed9fd16..4c0ce68 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -51,6 +51,13 @@ interface CreateDownloadListenerProps { successFunc: ((path: string) => Promise | void); } +interface ShakingOnSubmitProps { + doSubmit: () => Promise, + setSubmitting: React.Dispatch>, + setSubmitFailed: React.Dispatch>, + setShaking: React.Dispatch> +} + async function sleep(ms: number): Promise { return await new Promise((resolve, reject) => { setTimeout(resolve, ms); @@ -98,6 +105,21 @@ export default class ElementsUtil { setState(old => !start); } + static createShakingOnSubmit(props: ShakingOnSubmitProps): () => Promise { + const { doSubmit, setSubmitting, setSubmitFailed, setShaking } = props; + + return async () => { + setSubmitting(true); + const succeeded = await doSubmit(); + setSubmitting(false); + setSubmitFailed(!succeeded); + if (!succeeded) { + await ElementsUtil.delayToggleState(setShaking, 400); + } + } + + } + static async getImageBufferSrc(buffer: Buffer): Promise { const result = await FileType.fromBuffer(buffer); switch (result && result.mime) { diff --git a/src/client/webapp/styles/overlays.scss b/src/client/webapp/styles/overlays.scss index 55d1884..834f504 100644 --- a/src/client/webapp/styles/overlays.scss +++ b/src/client/webapp/styles/overlays.scss @@ -195,7 +195,6 @@ body > .overlay, } .buttons { - margin-left: 16px; display: flex; } } @@ -221,7 +220,7 @@ body > .overlay, > .content.add-guild { min-width: 350px; background-color: $background-secondary; - border-radius: 12px; + border-radius: 8px; .divider { margin: 16px; @@ -266,27 +265,6 @@ body > .overlay, margin-left: 16px; } } - - .lower { - padding: 16px; - border-bottom-left-radius: 8px; - border-bottom-right-radius: 8px; - background-color: $background-tertiary; - - .error { - background-color: $background-secondary-alt; - color: $text-normal; - } - - .error::first-letter { - text-transform: uppercase; - } - - .buttons { - margin-left: 16px; - display: flex; - } - } } /* Modify Channel Overlay */ @@ -296,13 +274,14 @@ body > .overlay, max-width: calc(100vw - 80px); .preview.channel-title { - border-top-left-radius: 12px; - border-top-right-radius: 12px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; padding-right: 8px; } - .text-input.channel-name { - text-transform: lowercase; + > .channel-name, + > .flavor-text { + margin: 16px; } }