Editable text component (#362)
* editable text component started * success and error callback implemented * feedback implemented and success or error state corrected * editable input ui fixed * PR comments resolved * isSaving state refactored from PR comment * icon import updated * Changing path * icon import fixed Co-authored-by: Satbir Singh <satbir121@gmail.com>
This commit is contained in:
parent
f35e2d7f05
commit
372fb3b33d
3
app/client/src/assets/icons/ads/edit.svg
Normal file
3
app/client/src/assets/icons/ads/edit.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2943 3L11.1507 5.11939L14.8564 8.78317L17 6.66379L13.2943 3ZM3.61901 12.9111L3 17L7.0907 16.1758C7.32717 16.1281 7.54455 16.0124 7.71608 15.8428L13.95 9.67931L10.2444 6.01552L3.97236 12.2167C3.78284 12.404 3.6589 12.6476 3.61901 12.9111Z" fill="#9F9F9F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
4
app/client/src/assets/icons/ads/error.svg
Normal file
4
app/client/src/assets/icons/ads/error.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="8" fill="#E22C2C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 9.24138L10.7586 12L12 10.7586L9.24138 8L12 5.24138L10.7586 4L8 6.75862L5.24138 4L4 5.24138L6.75862 8L4 10.7586L5.24138 12L8 9.24138Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
4
app/client/src/assets/icons/ads/success.svg
Normal file
4
app/client/src/assets/icons/ads/success.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="8" fill="#218358"/>
|
||||
<path d="M5.05273 8.1028L6.88208 9.81141L10.9475 5.89453" stroke="white" stroke-width="1.8" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
|
|
@ -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;
|
||||
}
|
||||
292
app/client/src/components/ads/EditableText.tsx
Normal file
292
app/client/src/components/ads/EditableText.tsx
Normal file
|
|
@ -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<string | boolean>(false);
|
||||
const [changeStarted, setChangeStarted] = useState<boolean>(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 (
|
||||
<EditableTextWrapper
|
||||
fill={props.fill}
|
||||
onMouseEnter={nonEditMode}
|
||||
onDoubleClick={
|
||||
props.editInteractionKind === EditInteractionKind.DOUBLE
|
||||
? editMode
|
||||
: noop
|
||||
}
|
||||
onClick={
|
||||
props.editInteractionKind === EditInteractionKind.SINGLE
|
||||
? editMode
|
||||
: noop
|
||||
}
|
||||
>
|
||||
<TextContainer
|
||||
isInvalid={!!isInvalid}
|
||||
isEditing={isEditing}
|
||||
bgColor={bgColor}
|
||||
>
|
||||
<BlueprintEditableText
|
||||
disabled={!isEditing}
|
||||
isEditing={isEditing}
|
||||
onChange={onInputchange}
|
||||
onConfirm={onConfirm}
|
||||
value={value}
|
||||
selectAllOnFocus
|
||||
placeholder={props.placeholder}
|
||||
className={props.className}
|
||||
onCancel={onConfirm}
|
||||
/>
|
||||
|
||||
<IconWrapper className="icon-wrapper">
|
||||
{savingState.isSaving ? (
|
||||
<Spinner size={Size.large} />
|
||||
) : (
|
||||
<Icon name={iconName} size={Size.large} />
|
||||
)}
|
||||
</IconWrapper>
|
||||
</TextContainer>
|
||||
{isEditing && !!isInvalid ? (
|
||||
<Text type={TextType.P2} className="error-message">
|
||||
{isInvalid}
|
||||
</Text>
|
||||
) : null}
|
||||
</EditableTextWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
AdsEditableText.defaultProps = {
|
||||
fill: false,
|
||||
};
|
||||
|
||||
export default AdsEditableText;
|
||||
|
|
@ -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<IconProps>`
|
|||
}
|
||||
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 = <BillingIcon />;
|
||||
break;
|
||||
case "edit":
|
||||
returnIcon = <EditIcon />;
|
||||
break;
|
||||
case "error":
|
||||
returnIcon = <ErrorIcon />;
|
||||
break;
|
||||
case "success":
|
||||
returnIcon = <SuccessIcon />;
|
||||
break;
|
||||
case "search":
|
||||
returnIcon = <SearchIcon />;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
60
app/client/src/components/stories/EditableText.stories.tsx
Normal file
60
app/client/src/components/stories/EditableText.stories.tsx
Normal file
|
|
@ -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 = () => (
|
||||
<div style={{ padding: "50px", background: "black", height: "500px" }}>
|
||||
<AdsEditableText
|
||||
defaultValue={text("defaultValue", "Product design app")}
|
||||
editInteractionKind={select(
|
||||
"editInteractionKind",
|
||||
[EditInteractionKind.SINGLE, EditInteractionKind.DOUBLE],
|
||||
EditInteractionKind.SINGLE,
|
||||
)}
|
||||
onTextChanged={action("text-changed")}
|
||||
valueTransform={value => 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)
|
||||
}
|
||||
></AdsEditableText>
|
||||
</div>
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user