diff --git a/app/client/src/assets/icons/ads/edit.svg b/app/client/src/assets/icons/ads/edit.svg new file mode 100644 index 0000000000..93eaaa6946 --- /dev/null +++ b/app/client/src/assets/icons/ads/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/assets/icons/ads/error.svg b/app/client/src/assets/icons/ads/error.svg new file mode 100644 index 0000000000..43f866b350 --- /dev/null +++ b/app/client/src/assets/icons/ads/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/assets/icons/ads/success.svg b/app/client/src/assets/icons/ads/success.svg new file mode 100644 index 0000000000..c5c3d40e41 --- /dev/null +++ b/app/client/src/assets/icons/ads/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/components/ads/EditableInput.tsx b/app/client/src/components/ads/EditableInput.tsx deleted file mode 100644 index 816ad2d1b3..0000000000 --- a/app/client/src/components/ads/EditableInput.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonComponentProps } from "./common"; - -export enum EditInteractionKind { - SINGLE, - DOUBLE, -} - -type EditableTextProps = CommonComponentProps & { - type: "text" | "password" | "email" | "phone" | "date"; - defaultValue: string; - onTextChanged: (value: string) => void; - placeholder: string; - cypressSelector?: string; - valueTransform?: (value: string) => string; - isEditingDefault?: boolean; - forceDefault?: boolean; - updating?: boolean; - isInvalid?: (value: string) => string | boolean; - editInteractionKind: EditInteractionKind; - hideEditIcon?: boolean; -}; - -// Check EditableText Component -export default function(props: EditableTextProps) { - return null; -} diff --git a/app/client/src/components/ads/EditableText.tsx b/app/client/src/components/ads/EditableText.tsx new file mode 100644 index 0000000000..1a4aa7f9a6 --- /dev/null +++ b/app/client/src/components/ads/EditableText.tsx @@ -0,0 +1,292 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { EditableText as BlueprintEditableText } from "@blueprintjs/core"; +import styled from "styled-components"; +import { Size } from "./Button"; +import Text, { TextType } from "./Text"; +import Spinner from "./Spinner"; +import { hexToRgba } from "./common"; +import { theme } from "constants/DefaultTheme"; +import { noop } from "lodash"; +import Icon from "./Icon"; + +export enum EditInteractionKind { + SINGLE = "SINGLE", + DOUBLE = "DOUBLE", +} + +export type SavingStateHandler = ( + isSaving: boolean, + state?: SavingState, +) => void; + +export enum SavingState { + NOT_STARTED = "NOT_STARTED", + SUCCESS = "SUCCESS", + ERROR = "ERROR", +} + +type EditableTextProps = { + defaultValue: string; + onTextChanged: (value: string) => void; + placeholder: string; + className?: string; + valueTransform?: (value: string) => string; + isEditingDefault?: boolean; + forceDefault?: boolean; + updating?: boolean; + isInvalid?: (value: string) => string | boolean; + editInteractionKind: EditInteractionKind; + hideEditIcon?: boolean; + fill?: boolean; + onSubmit: ( + value: string, + callback: SavingStateHandler, + ) => { saving: SavingState }; +}; + +const EditableTextWrapper = styled.div<{ + fill?: boolean; +}>` + width: ${props => (!props.fill ? "234px" : "100%")}; + .error-message { + color: ${props => props.theme.colors.danger.main}; + } +`; + +const editModeBgcolor = ( + isInvalid: boolean, + isEditing: boolean, + savingState: { isSaving: boolean; name?: SavingState }, +): string => { + if ( + (isInvalid && isEditing) || + (!savingState.isSaving && savingState.name === SavingState.ERROR) + ) { + return hexToRgba(theme.colors.danger.main, 0.08); + } else if (!isInvalid && isEditing) { + return theme.colors.blackShades[2]; + } else { + return "transparent"; + } +}; + +const TextContainer = styled.div<{ + isInvalid: boolean; + isEditing: boolean; + bgColor: string; +}>` + display: flex; + align-items: center; + ${props => + props.isEditing && props.isInvalid + ? `margin-bottom: ${props.theme.spaces[2]}px` + : null}; + .bp3-editable-text.bp3-editable-text-editing::before, + .bp3-editable-text.bp3-disabled::before { + display: none; + } + + &&& .bp3-editable-text-content, + &&& .bp3-editable-text-input { + font-size: ${props => props.theme.typography.p1.fontSize}px; + line-height: ${props => props.theme.typography.p1.lineHeight}px; + letter-spacing: ${props => props.theme.typography.p1.letterSpacing}px; + font-weight: ${props => props.theme.typography.p1.fontWeight}px; + } + + & .bp3-editable-text-content { + cursor: pointer; + color: ${props => props.theme.colors.blackShades[9]}; + overflow: hidden; + text-overflow: ellipsis; + ${props => (props.isEditing ? "display: none" : "display: block")}; + } + + & .bp3-editable-text-input { + border: none; + outline: none; + height: ${props => props.theme.spaces[13] + 3}px; + padding: ${props => props.theme.spaces[0]}px; + color: ${props => props.theme.colors.blackShades[9]}; + min-width: 100%; + border-radius: ${props => props.theme.spaces[0]}px; + } + + & .bp3-editable-text { + overflow: hidden; + padding: ${props => props.theme.spaces[4]}px + ${props => props.theme.spaces[5]}px; + width: calc(100% - 40px); + background-color: ${props => props.bgColor}; + } + + .icon-wrapper { + background-color: ${props => props.bgColor}; + } +`; + +const IconWrapper = styled.div` + width: ${props => props.theme.spaces[13] + 4}px; + padding-right: ${props => props.theme.spaces[5]}px; + height: ${props => props.theme.spaces[13] + 3}px; + display: flex; + align-items: center; + justify-content: flex-end; +`; + +export const AdsEditableText = (props: EditableTextProps) => { + const [isEditing, setIsEditing] = useState(!!props.isEditingDefault); + const [value, setValue] = useState(props.defaultValue); + const [lastValidValue, setLastValidValue] = useState(props.defaultValue); + const [isInvalid, setIsInvalid] = useState(false); + const [changeStarted, setChangeStarted] = useState(false); + const [savingState, setSavingState] = useState<{ + isSaving: boolean; + name?: SavingState; + }>({ isSaving: false, name: SavingState.NOT_STARTED }); + + useEffect(() => { + setValue(props.defaultValue); + setIsEditing(!!props.isEditingDefault); + }, [props.defaultValue, props.isEditingDefault]); + + useEffect(() => { + if (props.forceDefault === true) setValue(props.defaultValue); + }, [props.forceDefault, props.defaultValue]); + + const bgColor = useMemo( + () => editModeBgcolor(!!isInvalid, isEditing, savingState), + [isInvalid, isEditing, savingState], + ); + + /* should I write ? */ + const editMode = useCallback((e: React.MouseEvent) => { + setIsEditing(true); + const errorMessage = props.isInvalid && props.isInvalid(props.defaultValue); + setIsInvalid(errorMessage ? errorMessage : false); + e.preventDefault(); + e.stopPropagation(); + }, []); + + const onConfirm = (_value: string) => { + if ( + (!savingState.isSaving && savingState.name === SavingState.ERROR) || + isInvalid + ) { + setValue(lastValidValue); + setSavingState({ isSaving: false, name: SavingState.NOT_STARTED }); + } else if (changeStarted) { + props.onTextChanged(_value); + props.onSubmit(_value, SavingStateHandler); + } + setIsEditing(false); + setChangeStarted(false); + }; + + const onInputchange = useCallback((_value: string) => { + let finalVal: string = _value; + if (props.valueTransform) { + finalVal = props.valueTransform(_value); + } + setValue(finalVal); + + const errorMessage = props.isInvalid && props.isInvalid(finalVal); + const error = errorMessage ? errorMessage : false; + if (!error) { + setLastValidValue(finalVal); + } + setIsInvalid(error); + setChangeStarted(true); + }, []); + + const SavingStateHandler = (isSaving: boolean, state?: SavingState) => { + setIsEditing(false); + if (isSaving) { + setSavingState({ isSaving: true }); + } else { + switch (state) { + case SavingState.SUCCESS: + setSavingState({ isSaving: false, name: SavingState.SUCCESS }); + break; + default: + setValue(props.defaultValue); + setSavingState({ isSaving: false, name: SavingState.NOT_STARTED }); + break; + } + } + }; + + const iconName = + !isEditing && savingState.name === SavingState.NOT_STARTED + ? "edit" + : !isEditing && savingState.name === SavingState.SUCCESS + ? "success" + : (isEditing && savingState.name === SavingState.ERROR) || + (isEditing && !!isInvalid) + ? "error" + : undefined; + + const nonEditMode = () => { + if ( + !isEditing && + !savingState.isSaving && + savingState.name === SavingState.SUCCESS + ) { + setSavingState({ isSaving: false, name: SavingState.NOT_STARTED }); + } + }; + + return ( + + + + + + {savingState.isSaving ? ( + + ) : ( + + )} + + + {isEditing && !!isInvalid ? ( + + {isInvalid} + + ) : null} + + ); +}; + +AdsEditableText.defaultProps = { + fill: false, +}; + +export default AdsEditableText; diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index c6a3a078fb..3f547a05d9 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -3,6 +3,9 @@ import { ReactComponent as DeleteIcon } from "assets/icons/ads/delete.svg"; import { ReactComponent as UserIcon } from "assets/icons/ads/user.svg"; import { ReactComponent as GeneralIcon } from "assets/icons/ads/general.svg"; import { ReactComponent as BillingIcon } from "assets/icons/ads/billing.svg"; +import { ReactComponent as EditIcon } from "assets/icons/ads/edit.svg"; +import { ReactComponent as ErrorIcon } from "assets/icons/ads/error.svg"; +import { ReactComponent as SuccessIcon } from "assets/icons/ads/success.svg"; import { ReactComponent as SearchIcon } from "assets/icons/ads/search.svg"; import { ReactComponent as CloseIcon } from "assets/icons/ads/close.svg"; import styled from "styled-components"; @@ -15,6 +18,9 @@ export type IconName = | "user" | "general" | "billing" + | "edit" + | "error" + | "success" | "search" | "close" | undefined; @@ -25,8 +31,10 @@ const IconWrapper = styled.div` } display: flex; svg { - width: ${props => sizeHandler(props)}px; - height: ${props => sizeHandler(props)}px; + width: ${props => + props.size ? sizeHandler(props) : props.theme.spaces[9]}px; + height: ${props => + props.size ? sizeHandler(props) : props.theme.spaces[9]}px; path { fill: ${props => props.theme.colors.blackShades[4]}; } @@ -71,6 +79,15 @@ const Icon = (props: IconProps) => { case "billing": returnIcon = ; break; + case "edit": + returnIcon = ; + break; + case "error": + returnIcon = ; + break; + case "success": + returnIcon = ; + break; case "search": returnIcon = ; break; diff --git a/app/client/src/components/stories/ColorSelector.stories.tsx b/app/client/src/components/stories/ColorSelector.stories.tsx index 8303226525..9ca90b491c 100644 --- a/app/client/src/components/stories/ColorSelector.stories.tsx +++ b/app/client/src/components/stories/ColorSelector.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import { action } from "@storybook/addon-actions"; -import ColorSelector, { appColorPalette } from "../ads/ColorSelector"; +import ColorSelector, { appColorPalette } from "components/ads/ColorSelector"; import { withKnobs, array, boolean } from "@storybook/addon-knobs"; import { withDesign } from "storybook-addon-designs"; diff --git a/app/client/src/components/stories/EditableText.stories.tsx b/app/client/src/components/stories/EditableText.stories.tsx new file mode 100644 index 0000000000..de5a12f3b3 --- /dev/null +++ b/app/client/src/components/stories/EditableText.stories.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { boolean, select, text, withKnobs } from "@storybook/addon-knobs"; +import { withDesign } from "storybook-addon-designs"; +import AdsEditableText, { + EditInteractionKind, + SavingStateHandler, + SavingState, +} from "../ads/EditableText"; +import { action } from "@storybook/addon-actions"; + +export default { + title: "EditableText", + component: AdsEditableText, + decorators: [withKnobs, withDesign], +}; + +const calls = (value: string, callback: any) => { + console.log("value", value); + + // setTimeout(() => { + // return callback(SavingState.ERROR); + // }, 2000); + + setTimeout(() => { + return callback(false, SavingState.SUCCESS); + }, 2000); + + return callback(true); +}; + +const errorFunction = (name: string) => { + if (name === "") { + return "Name cannot be empty"; + } else { + return false; + } +}; + +export const EditableTextStory = () => ( +
+ value.toUpperCase()} + placeholder={text("placeholder", "Edit input")} + hideEditIcon={boolean("hideEditIcon", false)} + isInvalid={name => errorFunction(name)} + isEditingDefault={boolean("isEditingDefault", false)} + fill={boolean("fill", false)} + onSubmit={(value: string, callback: SavingStateHandler) => + calls(value, callback) + } + > +
+);